Next js app route로 블로그 만들기
블로그를 구축하면서 했던 삽질,기록들 2023-07-29
블로그를 만들고자 했던 이유
정말 많은 개발 블로그 플랫폼이 존재합니다. 저 역시도 과거에 티스토리, velog를 자
주 사용했고, 이런 블로그 플랫폼은 간편하게 통계 시스템이나 디자인을 바꿀수도 있
는 등의 여러 기능을 제공해 줍니다.
그럼 왜 만드는데?
저만의 블로그를 만들고 싶었습니다. 여러 기능을 직접 만들 수도 있고, 프론트엔드개
발자가 되고 싶은 사람으로서 블로그를 직접 제 손으로 구축하는 경험은 정말 재밌을
것 같았습니다.
SEO를 어떻게 최적화할지, TOC를 어떻게 만들지, 다크모드는 어떻게 만들지... 고민하
고 개발하는 과정이 어렵지만 재밌을 거 같았어요.!
프론트엔드 개발자 분들이 만든 여러 블로그 템플릿들을 보면서 '블로그를 직접 내 손
으로 만들고 싶다' 라는 마음이 생기기도 했고, 기술적으로는 최근에 나온 Next js의
여러 기능을 블로그에 녹여보고도 싶었습니다.
블로그에 어떤게 필요하지??
제 생각에 블로그에 꼭 필요한 여러 가지는 다음과 같습니다.
반응형 디자인
다크모드
댓글 시스템
Google Search Console
사이트맵
Open Graph
글 포스팅
TOC
아름다운 디자인..?
통계 시스템 구축
위 리스트들을 하나씩 블로그에 입혀가는 것 만으로 많은 것을 배울 수 있고 경험할수
있다고 생각했습니다.
App 디렉토리
최근나온 Next js의 App 디렉토리를 사용해보았는데요, App 디렉토리 아래 모든 컴포
넌트는 기본적으로 서버 컴포넌트 가 됩니다.
일반적으로 사용하는 컴포넌트를 클라이언트 컴포넌트 로 분리를 명확히
하게 됩니다. 그래서 use client 이라는 명령 없이 일반적으로 클라이언
트 컴포넌트를 app에서 사용하면 에러를 볼 수 있습니다.
따라서 파일 최상단에 use client 명령어를 명시적으로 적어줘야 클라이언트 컴포넌트
로 작동하게 됩니다.
Layout
매 화면마다 공통되게 보여주게 될 요소들을 이 layout 컴포넌트에 작성하고 children
으로 하위 컴포넌트에 내려줍니다.
보통 _app.js에서 사용하는 역할을 layout가 대신하게 됩니다.
layout은 컴포넌트를 담지만 리랜더링 되지는 않습니다. 공통되게 보여
지는 meta요소들, Footer, 전역 스타일 요소들,,등등이 layout에 위치하고 하위 요소
들은 모두
에 들어가게 됩니다.
이 레이아웃은 2가지의 타입으로 분리됩니다.
Root Layout : 모든 라우트에 적용되는 레이아웃
Regular Layout : 특정 라우트에서 적용되는 레이아웃
동적 라우팅
Next13버전도 12버전처럼 브래킷 []을 사용합니다.
예를 들어 app디렉토리 안에 blogs폴더를 만들고 그안에 [slug]라는 폴더를 만들고 그
안에 page.jsx를 만듭니다.
해당 경우 폴더 구조가 app/blogs/[slug]이기 때문에 해당 params는 slug이 되고 이
slug에 string값이 저장됩니다. 이와 같은 동적 라우팅으로 글별로 동적인 라우팅을적
용할 수 있습니다.
글 포스팅
마크다운을 이용해 글을 포스팅하고 있습니다. 프로젝트 내의 posts폴더에서 마크다운
으로 파일을 만들고, 이 마크다운을 읽어와서 랜더링해주는 방식으로 진행됩니다.
Next js의
blog-starter 에
서 마크다운을 읽어오는 코드를 볼 수 있었습니다.
위 코드는 루트의 _posts폴더의 모든 파일을 읽어 해당 파일을 gray-matter라이브러
리로 배열화해서 불러오는 방식입니다.
예를 들어 루트의 _posts폴더에 다음의 마크다운 글이 있습니다.
그럼 이 gray-matter는 다음과 같이 객체를 만들어 반환해줍니다.
최종적으로 모든 _posts의 모든 마크다운 글을 읽어오는 getPostSlugs를 거치면
이런식으로 데이터가 불러와지고, 이 md파일의 이름을 갖고
getPostBySlug(slug,fields)함수를 호출하면 data 와
content 로 분리가 이루어집니다.
최종적으로
모든 파일에 대해 getPostBySlug를 한 후 이런식의 객체 배열형식이 되고 이 객체 배
열을 반환해줍니다. 이제 content 부분을 HTML구조로 랜더링을 해줘야합
니다.
마크다운 글 파싱해주기
문자열의 글을 HTML구조로 파싱해주는 라이브러리는 React-MarkDown 라이브러리를 사
용했습니다. children에 받은 글의 content부분을 넣어주면 잘 파싱이 동작합니다.
코드 블럭 꾸미기
코드블럭도 이쁘게 꾸미고 싶었습니다. 코드블럭을 꾸며주는 라이브러리는 정말정말많
습니다.
Exploring
the best syntax highlighting libraries 이 글을 참고하고 많은 고민을 했습니다
.
저는 자료가 가장 많고, 사람들이 많이 쓰고 다양한 스타일이 존재하는
react-syntax-highlighter 라이브러리를 사용했습니다.
OpenGraph
열심히 만든 블로그를 친구에게 공유했는데 다음과 같이 나오면 굉장히 슬플 것입니다
. 😭😭
오픈그래프를 사용하면
썸네일을 포함한 웹페이지 미리보기가 제공되기 때문에 대략적으로 이 페이지가 어떤
내용을 다루고 있는지 사용자가 쉽게 확인할 수 있습니다.
물론 SEO에 직접적으로 영향을 주진 않지만, 오픈그래프를 사용하면 사용자에게 더 좋
은 경험을 줄 수 있습니다!🙃 내가 만든 웹 사이트의 오픈그래프를 쉽게 다음의 사이
트에서 확인가능합니다!
오
픈그래프 보러가기
정적인 meta데이터를 쉽게 만들수 있습니다.
MetaData
다만 블로그 글 별로 제목이나 대표하는 이미지가 달라지기떄문에 openGraph를 글마다
동적으로 만들 필요가 생깁니다. 이 경우 다음과 같이
generateMetadata 함수를 통해 만들 수 있습니다!.!
사이트맵
사이트맵은 검색 엔진에 게시 될 자신의 웹사이트 콘텐츠의 크롤링 및 색인을 돕는 페
이지 목록의 역할을 합니다.
공식문서에 따르면 app 디렉토리 안에 sitemap.js를 만들면 쉽게 사이트맵을 생성가능
합니다.
다음과 같은 코드의 결과는 아래와 같습니다.
이렇게 쉽게 사이트맵을 생성 가능합니다!
더 개선할점
RSS 피드 만들기(기왕이면 자동으로!)
블로그 글에 목차 나타내기
노션과 연결해서 TIL띄워주기
뒤죽박죽인 코드정리하기
'use client' ;
export default function ClientComponent ( ) {
return < div > { children } < / div > ;
}
//src/app/layout.tsx
import './globals.css' ;
import Recoil from './Recoil' ;
import GlobalStyle from '@/style/globalStyle' ;
import { ThemeWrapper } from './themeWrapper' ;
import Navbar from '@/Component/Common/Navbar' ;
import Footer from '@/Component/Common/Footer' ;
import localFont from 'next/font/local' ;
import { Metadata } from 'next' ;
const baseFont = localFont ( {
src : './fonts/KyoboHandwriting2022khn.ttf' ,
display : 'swap' ,
} ) ;
export const metadata : Metadata = {
title : 'HJ`s Blog' ,
description : '개발관련 여러 지식을 기록하는 공간' ,
icons : {
icon : '/images/favicon.webp' ,
} ,
metadataBase : new URL ( 'https://hj-devlog.vercel.app/' ) ,
openGraph : {
url : 'https://hj-devlog.vercel.app' ,
title : 'HJ`s Blog' ,
description : '개발 관련 여러 지식을 기록하고, 정리하는 공간입니다' ,
images : [
{
url : 'https://avatars.githubusercontent.com/u/59411107?v=4' ,
width : 800 ,
height : 600 ,
} ,
{
url : 'https://avatars.githubusercontent.com/u/59411107?v=4' ,
width : 1800 ,
height : 1600 ,
alt : 'My custom alt' ,
} ,
] ,
type : 'website' ,
} ,
robots : {
index : true ,
follow : true ,
googleBot : {
index : true ,
follow : true ,
} ,
} ,
viewport : {
width : 'device-width' ,
initialScale : 1 ,
} ,
verification : {
google : 'g3Daim29whdK1ZzL1CE6pvkYyvSgM5-6C898-TVjiz0' ,
} ,
} ;
export default function RootLayout ( {
children ,
} : {
children : React . ReactNode ,
} ) {
return (
< html lang = "en" className = { baseFont . className } >
< body className = "App" >
< Recoil >
< ThemeWrapper >
< GlobalStyle / >
< Navbar / >
{ children }
< / ThemeWrapper >
< Footer / >
< / Recoil >
< / body >
< / html >
) ;
}
//app/blogs/[slug]/page.jsx
export default function BlogPage ( {
params ,
} : {
params : {
slug : string ,
} ,
} ) {
return < > < / > ;
}
//마크다운 글을 읽어오는 과정
import fs from 'fs' ;
import { join } from 'path' ;
import matter from 'gray-matter' ;
//현재 작업경로와 _posts폴더를 연결함.
const postsDirectory = join ( process . cwd ( ) , '_posts' ) ;
//post디렉토리의 모든 파일을 긁어옴.
export function getPostSlugs ( ) {
return fs . readdirSync ( postsDirectory ) ;
}
//slug에 해당하는 마크다운 파일을 읽어서 필드의 데이터를 꺼냄
export function getPostBySlug ( slug : string , fields : string [ ] = [ ] ) {
//md확장자 제거용
const realSlug = slug . replace ( / \.md$ / , '' ) ;
//파일의 내용을 utf8형식으로 가져옴
const fullPath = join ( postsDirectory , ` ${ realSlug } .md ` ) ;
const fileContents = fs . readFileSync ( fullPath , 'utf8' ) ;
//파일 내용을 data,content로 분류함.
const { data , content } = matter ( fileContents ) ;
type Items = {
[ key : string ] : string ,
} ;
const items : Items = { } ;
// Ensure only the minimal needed data is exposed
fields . forEach ( ( field ) => {
if ( field === 'slug' ) {
items [ field ] = realSlug ;
}
if ( field === 'content' ) {
items [ field ] = content ;
}
if ( typeof data [ field ] !== 'undefined' ) {
items [ field ] = data [ field ] ;
}
} ) ;
return items ;
}
export function getAllPosts ( fields : string [ ] = [ ] ) {
const slugs = getPostSlugs ( ) ;
const posts = slugs
. map ( ( slug ) => getPostBySlug ( slug , fields ) )
// sort posts by date in descending order
. sort ( ( post1 , post2 ) => ( post1 . date > post2 . date ? - 1 : 1 ) ) ;
return posts ;
}
//_posts/example.md
-- -
title : '제목입니다.'
date : '2023-07-30'
image : '/images/postImg/Js.png'
-- -
< h2 > 안녕하세요 < / h2 >
{
content : ` <h2>안녕하세요<h2> ` ,
title : '제목입니다' ,
date : '2023-07-30' ,
image : '/images/postImg/Js.png'
}
[
'ImperativeandDeclarative.md' ,
'JSAstTree.md' ,
'jstepp.md' ,
'modernJS.md' ,
'module.md' ,
'Nextblog.md' ,
'NodejsEventLoop.md' ,
'NotionClone.md' ,
'reflectionfirstof2023.md' ,
] ;
[
{
title : 'Next js app route로 블로그 만들기' ,
slug : 'Nextblog' ,
category : 'Next.js' ,
excerpt : '블로그를 구축하면서 했던 삽질,기록들' ,
date : '2023-07-29' ,
image : '/images/postImg/NextjsBlog.webp' ,
content : '블로그 글 내용 제목```js코드코드```' ,
} ,
{
title : '노션 클로닝 회고' ,
slug : 'NotionClone' ,
category : '회고' ,
excerpt : '자바스크립트와 더 친해진 것 같다..!' ,
date : '2023-07-18' ,
image : '/images/postImg/notion.png' ,
content : '블로그 글 내용 제목```js코드코드```' ,
} ,
{
title : '2023 상반기를 돌아보며' ,
slug : 'reflectionfirstof2023' ,
category : '회고' ,
excerpt : '나는 무엇을 했나!' ,
date : '2023-07-05' ,
image : '/images/postImg/blogbundleSize.png' ,
content : '블로그 글 내용 제목```js코드코드```' ,
} ,
] ;
< ReactMarkdown
rehypePlugins = { [ rehypeRaw ] }
components = { {
img : ( { node , ... props } ) => (
< img
src = { props . src || '' }
alt = " 마크다운 이미지 "
style = { {
maxWidth : '100%' ,
height : 'auto' ,
objectFit : 'contain' ,
} }
/>
) ,
code : ( { node , inline , children , ... props } ) => (
< CodeBlock > { children as string } </ CodeBlock >
) ,
} }
>
{ post . content }
</ ReactMarkdown >
export const metadata : Metadata = {
title : 'HJ`s Blog' ,
description : '개발관련 여러 지식을 기록하는 공간' ,
icons : {
icon : '/images/favicon.webp' ,
} ,
metadataBase : new URL ( 'https://hj-devlog.vercel.app/' ) ,
openGraph : {
url : 'https://hj-devlog.vercel.app' ,
title : 'HJ`s Blog' ,
description : '개발 관련 여러 지식을 기록하고, 정리하는 공간입니다' ,
images : [
{
url : 'https://avatars.githubusercontent.com/u/59411107?v=4' ,
width : 800 ,
height : 600 ,
} ,
{
url : 'https://avatars.githubusercontent.com/u/59411107?v=4' ,
width : 1800 ,
height : 1600 ,
alt : 'My custom alt' ,
} ,
] ,
type : 'website' ,
} ,
robots : {
index : true ,
follow : true ,
googleBot : {
index : true ,
follow : true ,
} ,
} ,
viewport : {
width : 'device-width' ,
initialScale : 1 ,
} ,
verification : {
google : 'g3Daim29whdK1ZzL1CE6pvkYyvSgM5-6C898-TVjiz0' ,
} ,
} ;
//src/app/blog/[slugs]/page.tsx
import { Metadata } from 'next' ;
export async function generateMetadata ( {
params ,
} : {
params : {
slug : string ,
} ,
} ) {
const post = getPostBySlug ( params . slug , [
'title' ,
'content' ,
'excerpt' ,
'date' ,
'author' ,
'image' ,
] ) ;
const openGraphImage = post . image ;
const dynamicMetaTag : Metadata = {
title : post . title ,
description : post . excerpt ,
openGraph : {
images : [
{
url : ` ${ post . image } ` ,
width : 800 ,
height : 600 ,
} ,
] ,
} ,
keywords : post . title ,
robots : {
index : true ,
follow : true ,
googleBot : {
index : true ,
follow : true ,
} ,
} ,
viewport : {
width : 'device-width' ,
initialScale : 1 ,
} ,
verification : {
google : 'g3Daim29whdK1ZzL1CE6pvkYyvSgM5-6C898-TVjiz0' ,
} ,
} ;
return dynamicMetaTag ;
}
import { MetadataRoute } from 'next' ;
export default function sitemap ( ) : MetadataRoute . Sitemap {
return [
{
url : 'https://acme.com' ,
lastModified : new Date ( ) ,
} ,
{
url : 'https://acme.com/about' ,
lastModified : new Date ( ) ,
} ,
{
url : 'https://acme.com/blog' ,
lastModified : new Date ( ) ,
} ,
] ;
}
< urlset xmlns = "http://www.sitemaps.org/schemas/sitemap/0.9" >
< url >
< loc > https : / / acme . com < / loc >
< lastmod > 2023 - 04 - 06T15 : 02 : 24 . 021Z < / lastmod >
< / url >
< url >
< loc > https : / / acme . com / about < / loc >
< lastmod > 2023 - 04 - 06T15 : 02 : 24 . 021Z < / lastmod >
< / url >
< url >
< loc > https : / / acme . com / blog < / loc >
< lastmod > 2023 - 04 - 06T15 : 02 : 24 . 021Z < / lastmod >
< / url >
< / urlset >